Skip to content

feat(ui): invitation flow smart routing#10589

Merged
Davidm4r merged 16 commits intomasterfrom
feat/PROWLER-1298-invitation-flow-smart-routing
Apr 9, 2026
Merged

feat(ui): invitation flow smart routing#10589
Davidm4r merged 16 commits intomasterfrom
feat/PROWLER-1298-invitation-flow-smart-routing

Conversation

@Davidm4r
Copy link
Copy Markdown
Contributor

@Davidm4r Davidm4r commented Apr 7, 2026

Context

Implements the invitation flow smart routing for PROWLER-1298. When a user clicks an invitation link, instead of being sent directly to sign-up, they are now routed through a smart router that detects their authentication state and guides them
accordingly (sign in, sign up, or auto-accept).

Fix PROWLER-1298

Description

This PR introduces a new /invitation/accept route that acts as a smart router for invitation links. The key changes are:

  • New acceptInvitation server action (ui/actions/invitations/invitation.ts): Calls POST /invitations/accept with the invitation token.
  • New /invitation/accept page and client component (ui/app/(auth)/invitation/accept/): A smart router that handles three scenarios:
    • Authenticated users: Automatically accepts the invitation and redirects to the dashboard.
    • Unauthenticated users: Shows a choice screen ("I have an account — Sign in" / "I'm new — Create an account").
    • Error states: Handles expired (410), already-used (400), wrong-email (404), and generic errors with appropriate messaging and retry options.
  • Guest-only layout (ui/app/(auth)/(guest-only)/layout.tsx): Moved sign-in and sign-up pages under a new (guest-only) route group that redirects authenticated users to /. The parent (auth) layout no longer performs this
    redirect, allowing the invitation accept page to work for both authenticated and unauthenticated users.
  • Invitation link updated (ui/components/invitations/invitation-details.tsx): Generated invitation links now point to /invitation/accept?invitation_token=... instead of /sign-up?invitation_token=....
  • Backward compatibility (ui/proxy.ts): Old /sign-up?invitation_token=... links are automatically redirected to the new /invitation/accept route.
  • Query parameter preservation (ui/auth.config.ts, ui/proxy.ts): callbackUrl now includes nextUrl.search so query parameters (like invitation_token) survive the sign-in redirect flow.
  • New E2E test (ui/tests/auth/auth-session-errors.spec.ts): Validates that query parameters are preserved in the callbackUrl during redirects.

Steps to review

  1. Review the smart router logic in ui/app/(auth)/invitation/accept/accept-invitation-client.tsx — verify that all state transitions (loading → accepting → success/error, choose → sign-in/sign-up) are correct.
  2. Verify the (guest-only) route group correctly restricts sign-in/sign-up to unauthenticated users while leaving /invitation/accept accessible to both.
  3. Check backward compatibility: old invitation links (/sign-up?invitation_token=...) should redirect to /invitation/accept?invitation_token=... via the proxy middleware.
  4. Confirm that callbackUrl now preserves query parameters in both auth.config.ts and proxy.ts.
  5. Review error mapping in mapApiError for correct HTTP status code handling (410, 400, 404).
  6. Run the E2E test suite for auth session errors: npx playwright test ui/tests/auth/auth-session-errors.spec.ts.

Checklist

Community Checklist
  • This feature/issue is listed in here or roadmap.prowler.com
  • Is it assigned to me, if not, request it via the issue/feature in here or Prowler Community Slack

SDK/CLI

  • Are there new checks included in this PR? No

UI

  • All issue/task requirements work as expected on the UI
  • Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px)
  • Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px)
  • Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px)
  • Ensure new entries are added to CHANGELOG.md, if applicable.

API

N/A — No API changes in this PR.

License

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@Davidm4r Davidm4r requested a review from a team as a code owner April 7, 2026 08:44
@github-actions github-actions bot added component/ui community Opened by the Community labels Apr 7, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

Conflict Markers Resolved

All conflict markers have been successfully resolved in this pull request.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

✅ All necessary CHANGELOG.md files have been updated.

@Davidm4r Davidm4r changed the title Feat/prowler 1298 invitation flow smart routing feat(ui): invitation flow smart routing Apr 7, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

🔒 Container Security Scan

Image: prowler-ui:8407796
Last scan: 2026-04-08 12:52:39 UTC

✅ No Vulnerabilities Detected

The container image passed all security checks. No known CVEs were found.

📋 Resources:

@jfagoagas jfagoagas removed the community Opened by the Community label Apr 7, 2026
Copy link
Copy Markdown
Contributor

@alejandrobailo alejandrobailo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Found 2 issues:

  1. useCallback is not allowed per AGENTS.md CRITICAL RULES ("NEVER: useMemo, useCallback — React Compiler handles optimization"). doAccept can be a plain async function inside the component, the compiler will take care of referential stability.

import { useCallback, useEffect, useRef, useState } from "react";

const doAccept = useCallback(async () => {
if (!token) return;
setState({ kind: "accepting" });
const result = await acceptInvitation(token);
if (result?.error) {
const { message, canRetry } = mapApiError(result.status);
setState({ kind: "error", message, canRetry });
} else {
setState({ kind: "success" });
router.push("/");

  1. isInvitationPage in auth.config.ts is too broad. It uses startsWith("/invitation"), which lets any path under /invitation/* bypass auth in the NextAuth layer. proxy.ts was already narrowed to /invitation/accept only (commit "restrict public route to /invitation/accept only"), but auth.config.ts was missed. Should be:
const isInvitationPage = nextUrl.pathname.startsWith("/invitation/accept");

const isInvitationPage = nextUrl.pathname.startsWith("/invitation");

Overall the architecture is clean, nice work splitting the (guest-only) layout and the backward-compat redirect with the action=signup escape hatch. Just these two to tidy up 👍

Davidm4r and others added 12 commits April 8, 2026 09:27
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…298)

The backward-compatibility redirect in proxy.ts was catching the
sign-up navigation from the smart router, sending the user back to
/invitation/accept in an infinite loop. Adding an `action=signup`
query param lets the middleware distinguish smart-router navigations
from legacy invitation links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Narrow the public route from /invitation to /invitation/accept to
follow the principle of least privilege.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Next.js 16 requires a Suspense boundary for pages using dynamic
searchParams to allow static generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The real cause of the prerender error was NavigationProgress in the
(auth) layout using useSearchParams without a Suspense boundary.
Other (auth) pages didn't hit this because the (guest-only) layout
calls auth() which makes them dynamic. /invitation/accept sits
directly under (auth) so Next.js tried to prerender it and failed.

Reverts the force-dynamic and Suspense workarounds from page.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Url (PROWLER-1298)

The middleware now correctly preserves query parameters in callbackUrl
when redirecting to sign-in. Update the test assertion to match the
fixed behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove useCallback from doAccept (React Compiler handles optimization)
- Restrict isInvitationPage to /invitation/accept only in auth.config.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pfe-nazaries pfe-nazaries force-pushed the feat/PROWLER-1298-invitation-flow-smart-routing branch from 9ae299a to 837a84f Compare April 8, 2026 07:50
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
alejandrobailo
alejandrobailo previously approved these changes Apr 8, 2026
Copy link
Copy Markdown
Contributor

@Alan-TheGentleman Alan-TheGentleman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seven things to address. The first three I'd consider blocking, the rest are smaller but worth doing in the same PR.

  1. /sign-in button in the 404 error state is a dead end for authenticated users. When the API returns 404 (wrong-email case), the user is logged in with the wrong account and clicks "Go to Sign In" — but /sign-in now lives under (guest-only) layout, which redirects authenticated users back to /. They land on the wrong-account dashboard with no recovery path. Fix: gate on the 404 case and sign out before redirect: await signOut({ redirect: false }); router.push("/sign-in?callbackUrl=" + encodeURIComponent(callbackPath)).

  2. Error mapping by HTTP status is fragile. Switching on 400/404/410 assumes those codes only ever mean "already used", "wrong email", "expired" — but the API will inevitably return 400 for malformed tokens and 404 for nonexistent invitations too, both of which will surface the wrong message. handleApiResponse already exposes errors[0] from the JSON:API body — switch on result.errors?.[0]?.code and keep status only as a fallback. If the API doesn't have stable error codes yet, open a follow-up.

  3. E2E coverage is too thin for an auth flow refactor. The new test only validates query param preservation in callbackUrl. Missing: authenticated happy path (token + auth → accept → dashboard), unauthenticated choose → sign-in → back to accept, the three error states (410/400/404), the backward-compat /sign-up?invitation_token= redirect, and the (guest-only) redirect of authenticated users. ui/CLAUDE.md marks tdd as mandatory for refactors — add at least the four flow tests or link a follow-up ticket here before merge.

  4. acceptInvitation server action accepts unvalidated input. The token comes from a URL search param (attacker-controllable) and goes straight to JSON.stringify without Zod validation. The convention in ui/CLAUDE.md is all inputs validated with Zod. Add z.object({ token: z.string().min(1).max(500) }) at the entry of the action.

  5. action=signup is a magic string contract between proxy.ts and the client. The bypass works because both files happen to use the literal "signup", but there's no shared definition — six months from now someone refactors the middleware, sees the unexplained action param, and removes it, silently breaking the smart-router-to-sign-up path. Extract to ui/lib/invitation-routing.ts as export const INVITATION_SIGNUP_ACTION = "signup" and import on both sides.

  6. kind: "loading" is dead state. The useState lazy initializer sets it for the auth+token case, but the useEffect immediately overwrites it with kind: "accepting" — both render the same spinner. Drop loading from the union and start in accepting. Better still: move the auth+token decision into page.tsx (server component) so the happy path doesn't flash a client loading state at all.

  7. setState({ kind: "success" }) + router.push("/") race. The success UI may or may not flash for a tick depending on timing. Either drop the success state and navigate immediately, or keep it visible with an 800–1000ms delay before navigation.

What's good: the (guest-only) route group is the right Next.js 15 pattern, the discriminated union on state instead of boolean flags, the backward-compat in proxy.ts thinking about users with old links in their inbox, and the callbackUrl + nextUrl.search fix is the correct shape with an E2E test backing it.

- Sign out before redirect on 404 error (wrong email) to avoid dead end
- Remove dead states: "loading" and "success" from AcceptState union
- Add Zod validation to acceptInvitation server action
- Extract shared INVITATION_ACTION_PARAM/SIGNUP_ACTION constants
- Follow-ups: error codes (#10612), E2E coverage (#10613)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@Alan-TheGentleman Alan-TheGentleman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after f742f6c. Five of seven items addressed cleanly: the sign-out dead end is fixed with needsSignOut + handleSignOutAndRedirect, Zod validation is in, magic strings extracted to invitation-routing.ts with a good JSDoc, and both dead states (loading + success) are gone — the union is now just no-token | accepting | error | choose.

Two remaining items are acceptable as follow-ups:

  1. Error mapping still switches on HTTP status. When the API adds stable error codes to the JSON:API errors array, switch to result.errors?.[0]?.code. Until then the current mapping works, just fragile.

  2. E2E coverage. The auth flow refactor has one new test (query param preservation) but no coverage of the smart router states, backward-compat redirect, or guest-only layout redirect. Worth a follow-up PR or ticket to close the TDD gap before the next round of changes to this flow.

Clean iteration — good turnaround.

@Davidm4r Davidm4r merged commit baf1194 into master Apr 9, 2026
37 checks passed
@Davidm4r Davidm4r deleted the feat/PROWLER-1298-invitation-flow-smart-routing branch April 9, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants